SPE: reply-paced LCD polling and a calmer freshness gate — no more mirror blink - #5542
SPE: reply-paced LCD polling and a calmer freshness gate — no more mirror blink#5542opalito wants to merge 5 commits into
Conversation
…ror. Principle II. The floating mirror visibly appeared and disappeared in cycles of a few seconds on a real 1.5K-FA over a ser2net telnet link (v26.9.2). Two compounding causes: - The 600 ms LCD cadence free-ran as an exact multiple of the 100 ms Status poll, so the two timers phase-lock (Qt coarse timers actively coalesce), and when the locked phase puts each 371-byte display reply across a status poll on the wire, display frames drop in consecutive bursts until clock drift walks the alignment out. The cadence now re-arms from each display reply, folding the amplifier's variable response latency into the period so no stable phase can form. - Freshness loss blanked the glass to the idle hint after two missed refreshes, turning every burst into a full appear/disappear blink. The gate now tolerates one lost frame (stale after three misses), and going stale dims the last image in place — Principle II: the mirror presents the device's last known screen as last-known, it does not pretend the screen ceased to exist. The FRONT PANEL keys still gate on freshness exactly as before; hard clears remain on disconnect and presentation switches, where the image is truly obsolete. Colour ratchet: +0 unique / +0 references / +0 setStyleSheet (the stale veil is an alpha over the existing color.spe.lcd.background token). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…n slow links. Principle II. With the cadence re-armed from each display reply, kLcdPollIntervalMs is an idle gap, not a worst-case-link period: a second request is never in flight before the previous 371-byte reply has fully arrived, so a slow proxy serial side stretches the effective cadence instead of building a request backlog, and the amplifier is never asked to interleave display blocks. 250 ms gives ~3.5 refreshes/s at 115200 (<25% of the wire with the Status poll included) and degrades to ~2/s on a 19200 link on its own. The staleness window becomes an absolute 1800 ms, sized to cover a lost frame plus a retry even at 9600 baud — where the 100 ms Status poll alone nearly saturates the wire, now noted in the design note with a >=57600 proxy recommendation. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
There was a problem hiding this comment.
Reviewed against the head checkout at bad920de. CI is green on all five checks (build, check-macos, check-windows, Static checks, Sanitizer option configures) — noted, not relied on: none of them exercise SPE cadence.
Findings below are reasoned from code, not reproduced at runtime — I have no build, no amplifier, and no ser2net proxy here.
1. Issue fit
Partially. #5541 names two causes and this PR addresses both in the right places: the freshness gate becomes an absolute 1.8 s window decoupled from the poll gap, and SpeApplet::setLcdFresh dims via SpeLcdWidget::setStale instead of clear()ing, with the hard clears correctly retained at setConnected(false)/clearTelemetry() (SpeApplet.cpp:665-666) and at the presentation switch. I walked the disconnect and the docked⇄floating paths and both do reach the idle glass, so the body's "returns to the idle glass only when truly obsolete" claim holds.
Cause 1 — the phase-lock — is where I could not make the code match the claim. The triage on #5541 specified "re-arm m_lcdTimer from the display reply and make it single-shot … needs a fallback re-arm on the timeout path too." This PR adds the reply re-arm but leaves m_lcdTimer a repeating timer still armed inside requestLcdFrame() (SpeConnection.cpp:96) — setSingleShot is set for m_reconnectTimer, m_powerOnTimer and m_lcdStaleTimer, never for m_lcdTimer. That solves the lost-reply retry the triage worried about, but it means the design's central invariant is not actually implemented. See Blocker 1.
2. Scope
| File | What it changes | Claimed by title/body? | Verdict |
|---|---|---|---|
docs/architecture/spe-expert-amplifier-design.md |
§11 request bullet rewritten for reply-pacing; freshness paragraph rewritten; new ser2net ≥57600 operator guidance | Yes (the baud recommendation is new operator-facing guidance, not in the issue) | In scope; the baud line is a small addition worth a maintainer glance |
src/core/SpeConnection.cpp |
Reply re-arms m_lcdTimer; comment reword at the ACK path |
Yes | In scope |
src/core/SpeConnection.h |
kLcdPollIntervalMs 600→250; kLcdStaleTimeoutMs derived→absolute 1800; comment updates |
Yes; #5541's fix direction names 250 ms and ~1.8 s explicitly, so this is a fix, not a smuggled preference | In scope |
src/gui/SpeApplet.cpp |
setLcdFresh dims instead of clears; setFloating clears first |
Yes | In scope |
src/gui/SpeLcdWidget.{h,cpp} |
setStale, veil paint, clear() also resets stale |
Yes | In scope |
Everything in the diff is explained by #5541. No CHANGELOG.md entry (correct). No settings, protocol verb, capability or public-API surface added. No guard or confirmation removed — the key gate on m_lcdFresh in updateCommandsEnabled() (SpeApplet.cpp:653) is untouched, which is the important negative: dimming instead of blanking does not loosen the TX-adjacent front-panel key gating.
3. Blockers
1. The "a second request is never in flight before the previous reply has fully arrived" invariant is not implemented, and it fails on exactly the slow link the doc uses to justify the 250 ms gap. (inline: docs/…/spe-expert-amplifier-design.md:323, src/core/SpeConnection.cpp:36)
m_lcdTimer is repeating, interval 250 ms, and requestLcdFrame() arms it at send time (SpeConnection.cpp:96). The reply re-arm only wins the race when the reply decodes within 250 ms of the request. When round-trip ≥ 250 ms the send-time timer fires first, issues a second request while the first reply is still arriving, and — because requestLcdFrame() restarts the timer — the loop free-runs at a fixed 250 ms send-to-send cadence with none of the amp's latency folded in.
That is the original bug's precondition, not its cure: a fixed cadence commensurate with the 100 ms status poll (LCM 500 ms, so every second display request lands on a status-poll boundary). And it lands precisely where the doc says the design degrades gracefully — the doc's own arithmetic puts a 371-byte display frame at ~390 ms on a 9600 baud proxy serial side, i.e. well past 250 ms. At 19200 the doc's own ~450 ms cadence implies ~200 ms of reply latency, leaving ~50 ms of margin against an amp response latency the same doc calls variable.
The old 600 ms free-run needed a 600 ms round trip to overlap; this needs 250 ms. The margin shrank 2.4× while the doc's claim got stronger.
Two ways out, both small — a maintainer should pick:
- What the triage specified:
m_lcdTimer.setSingleShot(true), re-arm only from the reply, and add the explicit timeout-path re-arm so a lost reply still retries (a separate, longer retry interval rather than reusing the 250 ms gap). - Or keep the repeating timer purely as a watchdog with an interval above worst-case RTT, and gate
requestLcdFrame()on anm_lcdRequestOutstandingflag cleared by the display callback.
Either way the doc sentence needs to state the condition (round trip < the gap) rather than assert the invariant unconditionally.
4. Nits (non-blocking)
- The raised collision probability is unaddressed and the triage asked for a decision on it. #5541's triage flagged that 600→250 ms raises display traffic ~2.4× and therefore raises per-status-poll collision probability even once bursts can't form, and proposed
pollTick()skipping a status request while a display reply is outstanding (with care that a skipped tick must not count towardm_silentPolls). The PR doesn't do it and the body doesn't say why not. Reasonable to defer — worth saying so explicitly. Needs maintainer decision. m_lcdTimer.start()in the display callback is unguarded (SpeConnection.cpp:36) whererequestLcdFrame()checksm_lcdWanted && m_connected. If anything on thelcdFrameReceivedchain ever tore the connection down, the timer would be re-armed after teardown stopped it and would then tick forever into an early return. I traced the current chain (MainWindow_Wiring.cpp:6496→SpeApplet::setLcdFrame→m_lcd->setFrame) and it is not reachable today; the adjacent pre-existingm_lcdStaleTimer.start()has the same shape. Cheap to make both conditional.- The veil alpha
170is an unmotivated magic number and not a token (SpeLcdWidget.cpp:148). It leaves ~33 % of the glass's original fg/bg contrast, which is the whole point — but "keeps the last screen for context" and "legible" are different bars, anddocs/a11y.mdasks 3:1 for non-text state distinctions. Concrete values are explicitly allowed as an intermediate there, with a follow-up issue for the token; worth filing one. - No test, and half the change does have a deterministic seam.
SpeLcdWidgetis a plainQWidget:setFrame→setStale(true)→clear()retaining/droppingm_hasFrameis assertable socket-free, and the triage on #5541 asked for exactly that plus a lost-reply re-arm check. The scheduler half genuinely has no injection seam (SpeConnectionowns itsQTcpSocketandrequestLcdFrame()early-returns on!m_connected), and I would not ask for a fake amplifier peer to get one. Calling this a nit rather than a blocker because the primary reported symptom's root cause sits on the untestable side — a maintainer could reasonably require the widget-side test. - #5541 carries
maintainer-review("Requires maintainer review before any action is taken") and this PR was opened a minute after the issue, before any ruling. Procedural, not a code problem.
5. What I tried to break (and failed)
- Stale-state leakage across reconnect. Chased whether
m_stalecan survive into a live frame:setFrame()clears it (SpeLcdWidget.cpp:57) andclear()clears it, so a fresh frame is never veiled. Held. clear()'s new early-return condition.if (!m_hasFrame && !m_stale) return;— I looked for the statem_stale == true, m_hasFrame == false(reachable:setResponding(false)clears the widget, then the 1.8 s stale timer fires and callssetStale(true)). It paints correctly (no veil branch, "waiting for display…" branch taken) and the nextclear()still resets both. Held.- Painter state under the veil.
p.setPen(Qt::NoPen)is set before the bezel and never changed beforedrawRect, so the veil has no stray 1px stroke, and the veil/!m_hasFramebranches are mutually exclusive. Held. - Whether dimming loosens the front-panel key gate.
updateCommandsEnabled()still gates onm_lcdFresh(SpeApplet.cpp:653) andsetLcdFreshstill assigns it before dimming. The keys go dead on staleness exactly as before; only the glass changed. Held — this was the one I most expected to find a hole in. - The docked⇄floating
clear()ordering.m_lcd->clear()runs beforesetLcdFresh(false), so the subsequentsetStale(true)lands on an already-blank widget rather than veiling a previous session's image. Correct as written. - Could not check: anything runtime — the phase-lock claim, whether 1.8 s is the right window on the reporter's link, and whether the veil is legible on a real 1.5K-FA glass. The reporter's ser2net serial-side baud is not stated in #5541, which is what would settle whether Blocker 1 bites their station specifically; worth asking them.
6. Recommendation
Request changes, on Blocker 1 only. The freshness-gate and dim-don't-blank halves are well-reasoned, correctly scoped, and leave the safety-relevant key gating untouched — I tried several ways to break them and could not. But the reply-pacing half ships the doc's strongest claim without the code that makes it true, and the 250 ms gap is only defensible because of that claim; on a slow proxy serial side it degrades into a fixed-cadence free-run at 2.4× the old request rate, which is the reported bug with more traffic behind it.
Concrete next step: make m_lcdTimer single-shot with an explicit longer-interval retry on the lost-reply path (or add the in-flight guard), and condition the doc sentence on round-trip < gap. Then the maintainer decision on pollTick() skipping during an outstanding display reply can be taken on its own merits rather than as a load-bearing assumption.
Nice work on the root-cause writeup — the phase-lock explanation is the kind of thing that usually gets papered over with a bigger timeout.
🤖 aethersdr-agent · cost: $7.2874 · model: claude-opus-5
…pells. Principle VII. Hardware truth from the reporting 1.5K-FA: mid-transmit, display replies die on the wire far more often than Status replies — at the same bit-error rate a 371-byte frame is ~5x as exposed as a 76-byte one, and strong RF next to the serial run mid-transmit is exactly where the rate spikes. The amp also stops serving the display for a moment around OPERATE/STANDBY relay transitions. Validation (correctly, Principle VII) rejects the corrupted frames; presenting every such spell as a heavy dim made the mirror read as switched off through whole transmissions. (The parser itself is not at fault: a Python transliteration ran a 20-case torture suite over telnet-escaped inverse-video TX frames — attribute bytes full of 0xFF, splits inside doubled-IAC pairs, 0xFF checksums — and every case passed.) - FrameParser gains a display-reject callback, firing exactly once per complete display frame that fails validation in both its raw and telnet readings; covered by new protocol tests (no fire while a frame is merely incomplete; a clean frame after a reject still hands out). - SpeConnection re-requests 80 ms after a reject: each retry can only be provoked by a full received-and-rejected frame, so the retry stream is self-limited by the link's own serialization time. One clean frame a second is enough to keep the mirror live through a transmission. - Staleness window 1800 -> 2400 ms so relay-transition quiet spells no longer flap the freshness gate. - The stale veil lightens (alpha 170 -> 90 over the background token): the authoritative not-live signal is the disabled key group, not the depth of the dim. Colour ratchet stays +0/+0/+0 (verified strict vs main). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…y gate. Principle II. Field measurement on the reporting station (brightness-mapped from a screen recording, 81 s in plain STANDBY on a quiet band, no transmit): display-frame gaps of 0.5-4 s recur irregularly — eleven episodes in 81 seconds — with Status telemetry flowing throughout. Gaps like these are routine on a best-effort link (a network stall, the amp's own quiet spells; transmit-time RF makes them longer and denser but is not their only cause), so ANY visible staleness treatment fires constantly: the blank-to-idle glass blinked, and even the light dim read as the LCD switching off through every gap. So the glass now behaves like the amplifier's own LCD: it holds the newest picture it has, at full brightness, for as long as the connection lives. Freshness gates exactly one thing — the FRONT PANEL key group — which was always the actual safety property (no blind menu keystrokes). Hard clears remain where the image is truly obsolete: disconnect and presentation switches. The staleness transition is now logged with its window so field reports can measure real gap lengths. The reply-paced cadence, corrupted-frame retry, and 2400 ms key-gate window from the previous commits stay: they minimise how often the keys gate; this commit stops the glass from advertising it. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… Principle II. Review-caught (aethersdr-agent, PR aethersdr#5542): m_lcdTimer was a repeating timer armed at SEND time, so whenever the round trip exceeded the 250 ms gap the send-side timer fired before the reply and the loop free-ran at a fixed 250 ms send-to-send cadence — pipelining requests into a still-transmitting frame, on exactly the slow link the design note used to justify the small gap, and at a period commensurate with the 100 ms status poll (LCM 500 ms). The doc's central invariant was asserted, not implemented. As the issue triage specified: the timer is now single-shot; a request arms only a 1 s lost-reply fallback (sized above the worst plausible round trip — a 9600 baud proxy serial side spends ~390 ms serializing the frame alone), and only a decoded reply re-arms the short gap. Within the fallback a request is either answered or genuinely lost, never merely still in flight; a round trip beyond it is treated as lost and retried, accepting the overlap risk on a link that degenerate — the design note now states the condition instead of asserting the invariant unconditionally. The reply-side re-arms are also gated on m_lcdWanted && m_connected (the review's teardown-reentry nit). On a 115200 link (round trip well under the gap) the reply re-arm always won the race, so scheduling there is unchanged from the hardware-validated head. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
Thanks @aethersdr-agent — Blocker 1 is exactly right, and it's fixed in Blocker 1 (send-armed repeating timer defeats the no-overlap invariant) — accepted and fixed in What changed since
Nit: Hardware validation (answers §5's "could not check", including the serial-side baud question): a fork installer build of On the procedural note: acknowledged that #5541 carries 73 de Miguel, EC5W |
## What it does Adds `sweep-closed-prs` to `cache-cleanup.yml`: a daily (`17 4 * * *`) plus on-demand job that deletes caches on `refs/pull/N/merge` once the PR is no longer open. Those caches live on a ref only that PR can read. When it closes they become permanently unreachable but keep holding budget for up to the full 7-day retention, which is not configurable. ## Why scheduled, not `pull_request: closed` The obvious trigger cannot work, which is why #5008 pulled the original version rather than shipping it: for a `pull_request` event whose head is a fork, GitHub issues a **read-only** `GITHUB_TOKEN`, and a `permissions:` block can only lower that ceiling, never raise it. `gh cache delete` returns 403 and the job fails. **44 of the last 60 closed PRs here are from forks** (measured 2026-09-10). `pull_request_target` would get a writable token and is the documented-safe shape for a job that checks nothing out. It's deliberately not used: a schedule needs no privileged trigger at all, is idempotent, and **self-heals** — it picks up PRs closed while the workflow was broken, renamed, or disabled, which an event-driven job misses forever. The cost is latency, and it is not quite free: while the repo is over its allowance, dead entries compete with main's live ones for LRU until the sweep runs. If that bites, the job is idempotent and cheap enough to also hang off the existing `workflow_run` trigger, with the schedule kept as the backstop. Not done here; see follow-ups. ## Measured premise (2026-09-10, 25 days after #5008) The original draft held this PR until it could be shown that anything still accumulates on PR refs after #5008's save-on-main split. It does: ``` PR-ref caches: 20 entries, 6881 MiB repo usage: 23 entries, 9.88 GiB of 10 GiB #5458 MERGED 1720 MiB ← unreachable, swept by this job #5462 OPEN 396 MiB #5539 OPEN 1323 MiB #5542 OPEN 1720 MiB #5547 OPEN 1720 MiB ``` That is past the "> 2 GiB, or any single Qt-sized entry → merge" threshold the draft set for itself. The "PRs should restore rather than write" hypothesis did not hold: each open PR carries its own copy of the same 1,297 MiB Qt key main holds, because at the allowance LRU evicts main's copy between runs and the next PR re-saves it. This job reclaims the closed-PR leg of that loop; the loop itself is a ci.yml matter (follow-ups). ## Failure behaviour | case | behaviour | |---|---| | PR state unresolvable | Skipped and retried next run — a transient API error must not become data loss. Counted, and surfaced as a `::warning::` with gh's stderr so a persistent cause (missing scope, rate limit) cannot hide | | `--limit 500` truncation | Emits `::warning::`, because silent truncation looks exactly like "nothing to clean" | | Individual delete fails / LRU took it first | Deleted by id like prune-main; a 404 is logged, does not abort the loop, and does not count toward `freed` | | Dispatch overlaps the cron | `concurrency` group queues the second run | ## Cannot be exercised on this PR `schedule` and `workflow_dispatch` only fire from the **default branch**. First real run is after merge: trigger it via `workflow_dispatch` immediately rather than waiting for 04:17, and read the log before trusting it. That first run is also what confirms the `pull-requests: read` grant is sufficient for `gh pr view` under `GITHUB_TOKEN`. ## Not addressed here - Caches on **tag refs** (`refs/tags/v*`) from the release workflows are swept by neither job. Low volume; noted so it isn't silently forgotten. - The open-PR duplication loop above. The durable fix is giving the dependency caches (Qt, FFTW3, DeepFilterNet3, qtkeychain) the same restore-on-PR / save-on-main split #5008 gave the compiler caches; `install-qt-action`'s built-in cache has no restore-only mode and needs its own decision. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Summary
Closes #5541.
On v26.9.2, the floating LCD mirror from #5393 visibly appeared and disappeared in seconds-long cycles on a real 1.5K-FA over a ser2net telnet link. Bench iteration on that station (three screen recordings, one brightness-mapped frame-by-frame) established the ground truth: display-frame gaps of 0.5–4 s are routine on a best-effort link — eleven episodes in an 81 s recording taken in plain standby on a quiet band, with Status telemetry flowing throughout — and transmit-time RF makes them longer and denser (the 371-byte display reply is ~5x as exposed to bit errors as the 76-byte Status reply). Four changes:
SpeConnection): the cadence timer re-arms from each decoded display reply instead of free-running from the request. The original 600 ms period was an exact multiple of the 100 ms Status poll; two free-running coarse timers phase-lock (Qt deliberately coalesces their wakeups), and when the locked phase puts each display reply across a status poll on the wire, display frames drop in consecutive bursts until clock drift walks the alignment out. Folding the amp's variable response latency into the period makes a stable phase relationship impossible.FrameParsergains a display-reject callback, covered by new protocol tests: no fire while a frame is merely incomplete, exactly one fire per corrupted frame, and a clean frame afterward still hands out. One clean frame every second or two is all the mirror needs through a transmission. (The parser itself was exonerated first: a transliteration ran a 20-case torture suite over telnet-escaped inverse-video frames — attribute bytes full of 0xFF, splits inside doubled-IAC pairs, 0xFF checksum bytes — all passing.)SpeApplet::setLcdFresh): the mirror holds its newest image at full brightness for as long as the connection lives — exactly like the amplifier's own LCD holds its picture — and freshness gates exactly one thing, the FRONT PANEL key group, with the window widened to an absolute 2400 ms so relay-transition quiet spells don't flap it. Both visible treatments were field-tested and rejected on the real amp: blanking to the idle glass made the mirror blink in and out, and even a light dim read as the LCD switching off through every routine gap. The disabled key group is the one and only not-live signal — which was always the actual safety property (no blind menu keystrokes). Hard clears remain where the image is truly obsolete: disconnect and presentation switches. The staleness transition is logged with its window so field reports can measure real gap lengths.Why #5393's hardware validation didn't catch it: the builds validated on the real amp predated the freshness gate — they never blanked the mirror, so the same routine gaps were invisible (a gap was just a late refresh). The gate correctly made display liveness observable; this PR keeps the observability where it belongs (the key gate, plus a log line) and removes the burst-loss mechanisms that made it constant.
Colour ratchet, strict vs main, run locally: +0 unique / +0 references / +0 setStyleSheet.
Constitution principle honored
Principle II — the mirror renders the device's own screen as received and holds the newest picture it has, exactly like the device itself; late data gates the remote keys rather than being repainted as something it isn't. Principle VII unaffected: corrupted display frames are still discarded at the parser boundary (with the new reject hook and tests); this PR changes what happens after the discard.
Test plan
spe_protocol_testcases for the display-reject callbackChecklist
AppSettingscallsMeterSmoother(no meter changes)CHANGELOG.mduntouched73 de Miguel, EC5W
🤖 Generated with Claude Code